Skip to content

fix(runtime): reclaim credential-home locks whose owner names no process - #229

Open
rohanpoudel2 wants to merge 4 commits into
openai:mainfrom
rohanpoudel2:fix/lock-pid-validation
Open

fix(runtime): reclaim credential-home locks whose owner names no process#229
rohanpoudel2 wants to merge 4 commits into
openai:mainfrom
rohanpoudel2:fix/lock-pid-validation

Conversation

@rohanpoudel2

@rohanpoudel2 rohanpoudel2 commented Aug 3, 2026

Copy link
Copy Markdown

Refs #228

Problem

recoverStaleCredentialHomeLock consulted process.kill(pid, 0) for any owner.json whose pid was a number:

if (isRecord(owner) && typeof owner["pid"] === "number") {
  try {
    process.kill(owner["pid"], 0);
    return false;

POSIX gives two of those numbers special meanings — 0 signals the caller's own process group and -1 every process it may signal — so both always succeed and always report a live owner. A fractional or out-of-range value makes process.kill throw ERR_INVALID_ARG_TYPE, which is neither ESRCH nor EPERM and was rethrown raw out of a public API.

The age check sits in the else branch, so a lock naming any of these values was also exempt from it. acquireCodexSecurityCredentialHomeLock therefore waited on such a lock forever, at a 25 ms poll, with no message and no timeout — while every other stale path (missing or corrupt owner.json) has a 30 s escape hatch.

Measured against the public API on a lock aged 24 h, with a 1.5 s abort as the only way out:

pid 2147483647, dead (control)        ACQUIRED in 2ms
no owner.json (control)               ACQUIRED in 1ms
pid 0 -> own process group            ABORT_ERR: gave up after 1501ms
pid -1 -> every process               ABORT_ERR: gave up after 1501ms
pid 0.5 -> fractional                 ERR_INVALID_ARG_TYPE: gave up after 1ms
pid 2147483648 -> past int32          ERR_INVALID_ARG_TYPE: gave up after 1ms
pid 2**53 -> past safe integer        ERR_INVALID_ARG_TYPE: gave up after 1ms

Change

Consult process.kill only for an integer that is positive and inside the range it accepts:

// `process.kill` narrows its pid to a 32-bit signed integer and rejects anything that
// does not survive the round trip, so a larger value can never name a process.
const MAX_PROCESS_ID = 2_147_483_647;
...
typeof ownerPid === "number" &&
Number.isInteger(ownerPid) &&
ownerPid > 0 &&
ownerPid <= MAX_PROCESS_ID

Anything else is an owner that cannot be identified, and is now treated exactly like a missing one, so the existing 30 s age check reclaims the lock. All seven cases above now acquire in 2–3 ms.

The EPERM handling is unchanged and still means "the process exists but we may not signal it".

The upper bound is not a guess. Node's guard is pid != (pid | 0) — a ToInt32 round trip in its own JS layer, before any syscall — so the accepted range is exactly signed 32-bit, and it is a Node property rather than an OS one. Measured on Node v24.11.1:

2147483647     -> ESRCH                  (accepted; names no process)
2147483648     -> ERR_INVALID_ARG_TYPE
-2147483648    -> ESRCH                  (accepted)
-2147483649    -> ERR_INVALID_ARG_TYPE

Why not Number.isSafeInteger alone

That was this PR's first shape, and it leaves a gap: every integer from 2147483648 up to 2 ** 53 - 1 is a safe integer that process.kill still rejects. An owner.json naming one reached process.kill, and the argument error — neither ESRCH nor EPERM — escaped recoverStaleCredentialHomeLock and failed the acquisition outright instead of reclaiming the malformed lock:

owner pid Number.isSafeInteger only Number.isInteger + bound
2147483648 ERR_INVALID_ARG_TYPE ACQUIRED in 2 ms
2 ** 53 ACQUIRED in 1 ms ACQUIRED in 2 ms

Number.isInteger replaces Number.isSafeInteger in the final predicate because <= MAX_PROCESS_ID already subsumes it — every integer in the pid range is a safe integer.

Why not an absolute age ceiling on held locks

A genuinely reused pid is still indistinguishable from the original owner, so a lock left behind by a SIGKILLed scan is still never reclaimed. I left that out on purpose:

#228 documents that part, including why it is close to guaranteed in the shipped container (Dockerfile puts the state dir under the /output bind mount, and compose.yaml sets init: true, so container pids restart from 1 each run and a leftover low pid collides). Happy to implement the heartbeat if you tell me which shape you want.

Impact, stated plainly

Narrow. The only thing that changes is which owner.json values are believed to name a live process. A lock whose pid is a real, in-range pid behaves exactly as before: live still means live, EPERM still means live, ESRCH still means reclaimable. The values whose handling changes — 0, -1, fractional, anything past the int32 range — are never written by this code, which always writes process.pid. They arrive only from a corrupted or hand-edited lock, which is precisely the case that used to hang the scan forever or throw an argument error out of a public API.

Verification

recovers credential-home locks whose owner names no process walks [0, -1, 0.5, 2 ** 31, 2 ** 53], ages the lock past the 30 s threshold, and bounds the acquisition with an AbortController so a regression fails the test in 5 s instead of hanging it. 2 ** 31 is 2147483648, the first value process.kill rejects.

  • Whole change reverted (typeof ownerPid === "number" only): 0 pass / 1 failAbortError: The operation was aborted. after 5003.13 ms.
  • Only the range bound reverted (Number.isSafeInteger(ownerPid) && ownerPid > 0): 0 pass / 1 failERR_INVALID_ARG_TYPE: The "pid" argument must be of type number. Received type number (2147483648) after 11.52 ms.
  • With the change: 1 pass / 0 fail in 54 ms.

Full suite with the change: 739 pass / 5 skip / 0 fail across 34 files, 5169 expect() calls. With the range bound reverted the same command reports 736 pass / 5 skip / 3 fail; only one of those three is this test, the other two are the cli.test.ts npm-style-bin symlink cases, which fail intermittently on this machine under parallel load and pass on re-run with the change restored.

pnpm run types and pnpm run format both exit 0.

`recoverStaleCredentialHomeLock` consulted `process.kill(pid, 0)` for any
`owner.json` whose `pid` was a number. POSIX gives two of those numbers
special meanings: 0 signals the caller's own process group and -1 every
process it may signal, so both always succeed and always report a live
owner. A fractional or out-of-range value makes `process.kill` throw
`ERR_INVALID_ARG_TYPE`, which is neither ESRCH nor EPERM and was rethrown
raw out of a public API.

Because the age check sits in the `else` branch, a lock naming any of these
values was also exempt from it, so `acquireCodexSecurityCredentialHomeLock`
waited on it forever at a 25 ms poll with no message and no timeout.

Only consult `process.kill` for a positive safe integer. Anything else is an
owner that cannot be identified, and is now treated like a missing one, so
the existing 30 s age check reclaims the lock.

This does not address a genuinely reused pid, which needs a heartbeat rather
than a liveness probe and is a larger design change. That part is described
in the issue.

Refs openai#228
@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
@mldangelo-oai

Copy link
Copy Markdown
Collaborator

@codex review exact head 8f0bc4d

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8f0bc4d3a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sdk/typescript/src/runtime.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 8f0bc4d3a3

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

`process.kill` narrows its pid argument to a 32-bit signed integer and
throws ERR_INVALID_ARG_TYPE when the value does not survive that round
trip. `Number.isSafeInteger` still admits positive integers above that
range, such as 2147483648, so an aged `owner.json` naming one reached
`process.kill`, and the argument error - being neither ESRCH nor EPERM -
escaped `recoverStaleCredentialHomeLock` and failed the acquisition
outright instead of reclaiming the malformed lock.

Reject any owner pid above the maximum `process.kill` accepts so it is
treated as unidentifiable, letting the age check reclaim the lock, and
cover the gap between the pid range and the safe-integer range in the
stale-lock test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants